// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use std::{
    collections::{HashMap, HashSet},
    fmt,
    path::{Path, PathBuf, MAIN_SEPARATOR},
    sync::{Arc, Mutex},
};

use crate::config::FsScope;
pub use glob::Pattern;
use uuid::Uuid;

use crate::{Manager, Runtime};

/// Scope change event.
#[derive(Debug, Clone)]
pub enum Event {
    /// A path has been allowed.
    PathAllowed(PathBuf),
    /// A path has been forbidden.
    PathForbidden(PathBuf),
}

type EventListener = Box<dyn Fn(&Event) + Send>;

/// Scope for filesystem access.
#[derive(Clone)]
pub struct Scope {
    allowed_patterns: Arc<Mutex<HashSet<Pattern>>>,
    forbidden_patterns: Arc<Mutex<HashSet<Pattern>>>,
    event_listeners: Arc<Mutex<HashMap<Uuid, EventListener>>>,
}

impl fmt::Debug for Scope {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Scope")
            .field(
                "allowed_patterns",
                &self
                    .allowed_patterns
                    .lock()
                    .unwrap()
                    .iter()
                    .map(|p| p.as_str())
                    .collect::<Vec<&str>>(),
            )
            .field(
                "forbidden_patterns",
                &self
                    .forbidden_patterns
                    .lock()
                    .unwrap()
                    .iter()
                    .map(|p| p.as_str())
                    .collect::<Vec<&str>>(),
            )
            .finish()
    }
}

fn push_pattern<P: AsRef<Path>, F: Fn(&str) -> Result<Pattern, glob::PatternError>>(
    list: &mut HashSet<Pattern>,
    pattern: P,
    f: F,
) -> crate::Result<()> {
    let path: PathBuf = pattern.as_ref().components().collect();
    list.insert(f(&path.to_string_lossy())?);
    #[cfg(windows)]
    {
        if let Ok(p) = std::fs::canonicalize(&path) {
            list.insert(f(&p.to_string_lossy())?);
        } else {
            list.insert(f(&format!("\\\\?\\{}", path.display()))?);
        }
    }
    Ok(())
}

impl Scope {
    /// Creates a new scope from a `FsScope` configuration.
    pub(crate) fn new<R: Runtime, M: Manager<R>>(
        manager: &M,
        scope: &FsScope,
    ) -> crate::Result<Self> {
        let mut allowed_patterns = HashSet::new();
        for path in scope.allowed_paths() {
            if let Ok(path) = manager.path().parse(path) {
                push_pattern(&mut allowed_patterns, path, Pattern::new)?;
            }
        }

        let mut forbidden_patterns = HashSet::new();
        if let Some(forbidden_paths) = scope.forbidden_paths() {
            for path in forbidden_paths {
                if let Ok(path) = manager.path().parse(path) {
                    push_pattern(&mut forbidden_patterns, path, Pattern::new)?;
                }
            }
        }

        Ok(Self {
            allowed_patterns: Arc::new(Mutex::new(allowed_patterns)),
            forbidden_patterns: Arc::new(Mutex::new(forbidden_patterns)),
            event_listeners: Default::default(),
        })
    }

    /// The list of allowed patterns.
    pub fn allowed_patterns(&self) -> HashSet<Pattern> {
        self.allowed_patterns.lock().unwrap().clone()
    }

    /// The list of forbidden patterns.
    pub fn forbidden_patterns(&self) -> HashSet<Pattern> {
        self.forbidden_patterns.lock().unwrap().clone()
    }

    /// Listen to an event on this scope.
    pub fn listen<F: Fn(&Event) + Send + 'static>(&self, f: F) -> Uuid {
        let id = Uuid::new_v4();
        self.event_listeners.lock().unwrap().insert(id, Box::new(f));
        id
    }

    fn trigger(&self, event: Event) {
        let listeners = self.event_listeners.lock().unwrap();
        let handlers = listeners.values();
        for listener in handlers {
            listener(&event);
        }
    }

    /// Extend the allowed patterns with the given directory.
    ///
    /// After this function has been called, the frontend will be able to use the Tauri API to read
    /// the directory and all of its files. If `recursive` is `true`, subdirectories will be accessible too.
    pub fn allow_directory<P: AsRef<Path>>(&self, path: P, recursive: bool) -> crate::Result<()> {
        let path = path.as_ref();
        {
            let mut list = self.allowed_patterns.lock().unwrap();

            // allow the directory to be read
            push_pattern(&mut list, path, escaped_pattern)?;
            // allow its files and subdirectories to be read
            push_pattern(&mut list, path, |p| {
                escaped_pattern_with(p, if recursive { "**" } else { "*" })
            })?;
        }
        self.trigger(Event::PathAllowed(path.to_path_buf()));
        Ok(())
    }

    /// Extend the allowed patterns with the given file path.
    ///
    /// After this function has been called, the frontend will be able to use the Tauri API to read the contents of this file.
    pub fn allow_file<P: AsRef<Path>>(&self, path: P) -> crate::Result<()> {
        let path = path.as_ref();
        push_pattern(
            &mut self.allowed_patterns.lock().unwrap(),
            path,
            escaped_pattern,
        )?;
        self.trigger(Event::PathAllowed(path.to_path_buf()));
        Ok(())
    }

    /// Set the given directory path to be forbidden by this scope.
    ///
    /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**.
    pub fn forbid_directory<P: AsRef<Path>>(&self, path: P, recursive: bool) -> crate::Result<()> {
        let path = path.as_ref();
        {
            let mut list = self.forbidden_patterns.lock().unwrap();

            // allow the directory to be read
            push_pattern(&mut list, path, escaped_pattern)?;
            // allow its files and subdirectories to be read
            push_pattern(&mut list, path, |p| {
                escaped_pattern_with(p, if recursive { "**" } else { "*" })
            })?;
        }
        self.trigger(Event::PathForbidden(path.to_path_buf()));
        Ok(())
    }

    /// Set the given file path to be forbidden by this scope.
    ///
    /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**.
    pub fn forbid_file<P: AsRef<Path>>(&self, path: P) -> crate::Result<()> {
        let path = path.as_ref();
        push_pattern(
            &mut self.forbidden_patterns.lock().unwrap(),
            path,
            escaped_pattern,
        )?;
        self.trigger(Event::PathForbidden(path.to_path_buf()));
        Ok(())
    }

    /// Determines if the given path is allowed on this scope.
    pub fn is_allowed<P: AsRef<Path>>(&self, path: P) -> bool {
        let path = path.as_ref();
        let path = if !path.exists() {
            crate::Result::Ok(path.to_path_buf())
        } else {
            std::fs::canonicalize(path).map_err(Into::into)
        };

        if let Ok(path) = path {
            let path: PathBuf = path.components().collect();
            let options = glob::MatchOptions {
                // this is needed so `/dir/*` doesn't match files within subdirectories such as `/dir/subdir/file.txt`
                // see: https://github.com/tauri-apps/tauri/security/advisories/GHSA-6mv3-wm7j-h4w5
                require_literal_separator: true,
                // dotfiles are not supposed to be exposed by default
                #[cfg(unix)]
                require_literal_leading_dot: true,
                ..Default::default()
            };

            let forbidden = self
                .forbidden_patterns
                .lock()
                .unwrap()
                .iter()
                .any(|p| p.matches_path_with(&path, options));

            if forbidden {
                false
            } else {
                let allowed = self
                    .allowed_patterns
                    .lock()
                    .unwrap()
                    .iter()
                    .any(|p| p.matches_path_with(&path, options));
                allowed
            }
        } else {
            false
        }
    }
}

fn escaped_pattern(p: &str) -> Result<Pattern, glob::PatternError> {
    Pattern::new(&glob::Pattern::escape(p))
}

fn escaped_pattern_with(p: &str, append: &str) -> Result<Pattern, glob::PatternError> {
    Pattern::new(&format!(
        "{}{}{append}",
        glob::Pattern::escape(p),
        MAIN_SEPARATOR
    ))
}

#[cfg(test)]
mod tests {
    use super::Scope;

    fn new_scope() -> Scope {
        Scope {
            allowed_patterns: Default::default(),
            forbidden_patterns: Default::default(),
            event_listeners: Default::default(),
        }
    }

    #[test]
    fn path_is_escaped() {
        let scope = new_scope();
        #[cfg(unix)]
        {
            scope.allow_directory("/home/tauri/**", false).unwrap();
            assert!(scope.is_allowed("/home/tauri/**"));
            assert!(scope.is_allowed("/home/tauri/**/file"));
            assert!(!scope.is_allowed("/home/tauri/anyfile"));
        }
        #[cfg(windows)]
        {
            scope.allow_directory("C:\\home\\tauri\\**", false).unwrap();
            assert!(scope.is_allowed("C:\\home\\tauri\\**"));
            assert!(scope.is_allowed("C:\\home\\tauri\\**\\file"));
            assert!(!scope.is_allowed("C:\\home\\tauri\\anyfile"));
        }

        let scope = new_scope();
        #[cfg(unix)]
        {
            scope.allow_file("/home/tauri/**").unwrap();
            assert!(scope.is_allowed("/home/tauri/**"));
            assert!(!scope.is_allowed("/home/tauri/**/file"));
            assert!(!scope.is_allowed("/home/tauri/anyfile"));
        }
        #[cfg(windows)]
        {
            scope.allow_file("C:\\home\\tauri\\**").unwrap();
            assert!(scope.is_allowed("C:\\home\\tauri\\**"));
            assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
            assert!(!scope.is_allowed("C:\\home\\tauri\\anyfile"));
        }

        let scope = new_scope();
        #[cfg(unix)]
        {
            scope.allow_directory("/home/tauri", true).unwrap();
            scope.forbid_directory("/home/tauri/**", false).unwrap();
            assert!(!scope.is_allowed("/home/tauri/**"));
            assert!(!scope.is_allowed("/home/tauri/**/file"));
            assert!(scope.is_allowed("/home/tauri/**/inner/file"));
            assert!(scope.is_allowed("/home/tauri/inner/folder/anyfile"));
            assert!(scope.is_allowed("/home/tauri/anyfile"));
        }
        #[cfg(windows)]
        {
            scope.allow_directory("C:\\home\\tauri", true).unwrap();
            scope
                .forbid_directory("C:\\home\\tauri\\**", false)
                .unwrap();
            assert!(!scope.is_allowed("C:\\home\\tauri\\**"));
            assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
            assert!(scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
            assert!(scope.is_allowed("C:\\home\\tauri\\inner\\folder\\anyfile"));
            assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
        }

        let scope = new_scope();
        #[cfg(unix)]
        {
            scope.allow_directory("/home/tauri", true).unwrap();
            scope.forbid_file("/home/tauri/**").unwrap();
            assert!(!scope.is_allowed("/home/tauri/**"));
            assert!(scope.is_allowed("/home/tauri/**/file"));
            assert!(scope.is_allowed("/home/tauri/**/inner/file"));
            assert!(scope.is_allowed("/home/tauri/anyfile"));
        }
        #[cfg(windows)]
        {
            scope.allow_directory("C:\\home\\tauri", true).unwrap();
            scope.forbid_file("C:\\home\\tauri\\**").unwrap();
            assert!(!scope.is_allowed("C:\\home\\tauri\\**"));
            assert!(scope.is_allowed("C:\\home\\tauri\\**\\file"));
            assert!(scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
            assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
        }

        let scope = new_scope();
        #[cfg(unix)]
        {
            scope.allow_directory("/home/tauri", false).unwrap();
            assert!(scope.is_allowed("/home/tauri/**"));
            assert!(!scope.is_allowed("/home/tauri/**/file"));
            assert!(!scope.is_allowed("/home/tauri/**/inner/file"));
            assert!(scope.is_allowed("/home/tauri/anyfile"));
        }
        #[cfg(windows)]
        {
            scope.allow_directory("C:\\home\\tauri", false).unwrap();
            assert!(scope.is_allowed("C:\\home\\tauri\\**"));
            assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
            assert!(!scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
            assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
        }
    }
}
